Skip to content

fix(mcp): resolve superseded failed mutation receipts on later success - #693

Merged
zzet merged 2 commits into
zzet:mainfrom
tiendungdev:fix/reindex-clears-failed-receipt
Aug 29, 2026
Merged

fix(mcp): resolve superseded failed mutation receipts on later success#693
zzet merged 2 commits into
zzet:mainfrom
tiendungdev:fix/reindex-clears-failed-receipt

Conversation

@tiendungdev

@tiendungdev tiendungdev commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the rolling repo-wide change.detect lockout described in #692.

A mutation whose disk write succeeded but whose graph ingest failed leaves a terminally failed receipt in Server.mutationReceipts. awaitMutationFreshnessForRepos then refuses change.detect/change.impact for the whole repository until the receipt's 10-minute retention lapses — even though waiting can never heal a terminal failure. Under load, each failed ingest of an actively edited file re-arms the barrier for another retention window, so agents observe a seemingly permanent, uncleareable lockout.

Change

  • trackMutationTicket: when a ticket completes successfully (Err == nil && Reindexed), call the new resolveSupersededFailedReceipts, which drops terminally failed receipts for the same path with a lower generation. The graph then reflects newer bytes than the failed generation ever wrote, so the stale failure no longer describes a real freshness gap. Pending receipts and failures at or above the succeeded generation are untouched, as are failures on other paths.
  • Barrier error message: when a terminal failure is reported, append that terminally failed generations do not recover by waiting and clear on a later successful mutation of the path or on retention expiry. (Existing tests assert substrings, so this is additive.)

Tests

  • TestMutationFreshnessSuccessResolvesSupersededFailures: success resolves an older failed receipt for the same path; failures on another path and failures newer than the success survive and still fail the barrier; the superseded receipt no longer appears in the error.
  • TestTrackMutationTicketResolvesSupersededFailures: end-to-end through trackMutationTicket — a successful ticket for the path removes the stale failed receipt and keeps its own receipt for retention.
  • Deleting the resolve call makes the end-to-end test fail (verified), so the tests bind the behavior.

Verification

  • go vet ./internal/mcp/: clean.
  • Focused freshness tests pass on Windows and on Linux (golang:1.26 container).
  • Full ./internal/mcp suite in the Linux container: TestDetectChanges_EchoesMeasuredScope and TestFileMutationsReindexSynchronouslyAcrossConsecutiveEdits fail identically with and without this patch (both pass in isolation), so they appear to be pre-existing load-sensitive failures, not regressions from this change.

Scope (explicit, so #692 stays open)

  • workspace_admin.reindex goes through multiIndexer.IncrementalReindexRepo and never touches s.mutationReceipts, so a scoped reindex of the file still does not resolve a failed receipt. Not addressed here.
  • Under sustained load where every successive ingest also fails, nothing here clears anything — the resolve only runs on a success. This converts "blocked until retention" into "blocked until the next success", not the full rolling-lockout fix. Retry-with-backoff and degrade-instead-of-refuse remain open in Failed graph-ingest mutation receipt fail-closes change.detect repo-wide with no reconcile path; reindex does not clear it #692.
  • As of the review follow-up, superseded failures are resolved in place (stamped with the superseding apply) rather than deleted, so change.receipt keeps reporting truthfully.

Notes

The reproduction and measurements behind this are in #692 (including the correction comment with the retention/in-memory analysis). The other asks in that issue — an explicit reconcile operation, degrade-instead-of-refuse, and printing a queryable id in the refusal — are intentionally left out of this PR since they involve API-surface decisions.

🤖 Generated with Claude Code

A mutation whose disk write succeeded but whose graph ingest failed leaves
a terminally failed receipt in Server.mutationReceipts. The freshness
barrier then refuses change.detect/change.impact for the whole repository
until the receipt's 10-minute retention lapses, even though waiting can
never heal a terminal failure — and a later generation of the same path
that ingests successfully already proves the graph reflects newer bytes
than the failed generation ever wrote.

Drop terminally failed receipts for a path when a later generation of that
path completes successfully. Pending receipts and failures at or above the
succeeded generation are untouched. Also state in the barrier error that
terminally failed generations do not recover by waiting.

Fixes the rolling repo-wide detect lockout described in zzet#692: under load,
each failed ingest of an actively edited file re-armed the barrier for
another retention window, which agents could neither clear nor reconcile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the diagnosis in #692 and the follow-up correction are the good kind of bug report, and the core of this fix is right. I verified the load-bearing argument independently rather than reading it: a completed waiter always has RequestedGeneration <= AppliedGeneration, because completeMutationWaiters only completes waiters whose generation <= appliedGeneration (watcher.go:1774, and the storm path at :2042). So a success at requested generation G guarantees the graph reflects at least G's bytes, hence strictly newer than any failure below G — and thresholding on the requested rather than the applied generation is the conservative side of that. The claim holds.

Build, go vet, golangci-lint ./internal/mcp/... clean. The full ./internal/mcp suite passes here on macOS, and again under -race (198s, no data races) — including TestDetectChanges_EchoesMeasuredScope and TestFileMutationsReindexSynchronouslyAcrossConsecutiveEdits, which you saw fail in your Linux container. So those look environmental rather than pre-existing-and-known; worth a second look on your side, but not this PR's problem.

Mutants: removing the resolveSupersededFailedReceipts call fails TestTrackMutationTicketResolvesSupersededFailures, dropping the generation guard fails TestMutationFreshnessSuccessResolvesSupersededFailures, and dropping the path guard fails it too. Three of the four guards bind. The fourth is finding 2 below.

One regression I need fixed before this lands, and one gap.

1. change.receipt reports a superseded failure as pending, permanently

Deleting the receipt removes the only thing that could refresh the mutation-commit ledger's graph half. mutationStatusPayload refreshes a record's status via mutationReceiptState(record.pendingReindexReceipt()) and simply skips the refresh when the lookup misses — so a record whose edit response timed out stays graph_status: "pending" forever once its receipt is dropped.

Same probe, natural sequence (edit response times out → record is pending with receipt mutation-3; the ingest fails terminally; nobody polls in that window; a later generation of the same path succeeds), asserting only what change.receipt would print:

origin/main   after supersede   graph_status="failed"
this branch   after supersede   graph_status="pending"

failed is at least true of that generation and actionable. pending is neither — and mutationStatusGuidance for a committed write says "if graph_status is not "fresh" the graph is still catching up", so the caller is told to keep waiting for a generation that will never complete. That is exactly the failure mutationStatusPayload's own doc comment says the refresh exists to prevent ("every pending receipt would read as pending forever, which is precisely the stale answer this tool exists to avoid").

The window is not exotic — this PR widens it. Today an agent learns about the failed ingest from the detect refusal and then goes and reads the receipt. After this change the refusal is gone, so there is less reason to poll in the interval where the status is still truthful.

Worth contrasting with the batch path, which already models a vanishing receipt correctly: refreshBatchGraph clears file.ReindexReceipt on a lookup miss and re-admits the file (batch_transaction.go:909-922). mutationStatusPayload has no such fallback.

Suggested fix — don't delete, resolve. Stamp the superseding outcome onto the failed receipt instead of removing it. Prototyped locally; it is a few lines in place of the Delete:

other.mu.Lock()
if other.completed && (other.result.Err != nil || !other.result.Reindexed) {
    other.result = indexer.MutationResult{
        RequestedGeneration: other.generation,
        AppliedGeneration:   applied.AppliedGeneration,
        Reindexed:           true,
    }
}
other.mu.Unlock()

With that, the barrier clears exactly as it does now (I ran a barrier assertion against both variants — both pass), the receipt stays queryable, and change.receipt reports:

prototype     after supersede   graph_status="fresh"   (reindexed=true, applied=9)

which is not just better than this branch, it is better than main — the path genuinely is fresh once a later generation lands, and failed was already the wrong answer there. It also mirrors what the watcher itself does: completeMutationWaiters resolves every earlier waiter with the later apply's result rather than discarding it, so the MCP ledger would end up describing supersession the same way the layer beneath it does.

One caveat if you take this: read the succeeded result before the Range and pass it in (you already have result in hand at the call site in trackMutationTicket) rather than taking succeeded.mu.RLock() inside the loop while holding nothing — two concurrent resolves each holding their own read lock and reaching for the other's write lock is a deadlock shape that does not need to exist.

2. The "pending receipts are untouched" guard is the one that isn't tested

terminalFailure := true — i.e. delete every earlier-generation receipt for the path, in flight or not — leaves the entire ./internal/mcp suite green. Every receipt in TestMutationFreshnessSuccessResolvesSupersededFailures is already completed, so nothing exercises it.

That is the guard whose failure direction is unsafe: dropping a pending receipt removes a live freshness gap from the barrier rather than a dead one. Test that fails under that mutant and passes at head:

func TestMutationFreshnessSuccessKeepsPendingSamePathReceipts(t *testing.T) {
	s := &Server{mutationSafetyWait: time.Millisecond}
	pendingFreshnessReceipt(s, "receipt-inflight", "repo-a", "/repo-a/file.go", 4)

	succeeded := pendingFreshnessReceipt(s, "receipt-success", "repo-a", "/repo-a/file.go", 9)
	completeFreshnessReceipt(succeeded, indexer.MutationResult{
		RequestedGeneration: 9, AppliedGeneration: 9, Reindexed: true,
	})
	s.resolveSupersededFailedReceipts(succeeded)

	if _, loaded := s.mutationReceipts.Load("receipt-inflight"); !loaded {
		t.Fatal("a still-pending receipt for the same path was dropped by the resolve")
	}
	err := s.awaitMutationFreshnessForRepos(context.Background(), "repo-a")
	if err == nil || !strings.Contains(err.Error(), "receipt-inflight") {
		t.Fatalf("barrier no longer reports the in-flight receipt: %v", err)
	}
}

(Adjust the receipt-identity assertion if you take the resolve-in-place route.)

Scope, so #692 does not get closed by accident

trackMutationTicket is reached only from mutationReindexState and the batch transaction. reindex_repository goes through multiIndexer.IncrementalReindexRepo and never touches s.mutationReceipts, so a scoped workspace_admin.reindex(paths=[<file>]) still leaves the failed receipt blocking detect — request 1 in the issue, and the half of your own issue comment that said "and when a direct reindex of that path succeeds". Your PR body doesn't claim it and the new barrier text is careful not to promise it, which I appreciate; I'd just say so explicitly in the PR so the issue stays open for it.

Same for the shape you actually hit: under sustained load where each successive ingest also fails, nothing here clears anything, because the resolve only runs on a success. That is fine as a scoped first step — it converts "blocked until retention" into "blocked until the next success", which is a real improvement — but it is not the rolling-lockout fix, and the issue should keep tracking retry-with-backoff and degrade-instead-of-refuse separately.

The message addition is good and I'd keep it as is.

Review follow-up: deleting a superseded receipt left the mutation-commit
ledger's graph half refreshing against a missing id, so a record whose
edit response had timed out reported graph_status "pending" forever, with
guidance telling the caller to keep waiting. Stamp the superseding apply
onto the failed receipt instead, mirroring how completeMutationWaiters
resolves earlier waiters with the later apply's result; the receipt stays
queryable and reports fresh with the superseding applied generation.

The succeeded result is passed by value so the sweep holds no lock other
than the receipt it is stamping, removing the cross-receipt lock shape.

Add the missing pending-receipt guard test: a still-in-flight receipt for
the same path must survive the resolve untouched and still fail the
freshness barrier. All four guards now bind under mutation (call removed,
generation guard dropped, path guard dropped, pending stamped): each
mutant fails its test and the restored suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both findings addressed in 027959d — thank you for the independent verification of the generation invariant and for the prototype.

  1. Resolve in place, not delete. Took your prototype nearly verbatim: the stamp happens under the failed receipt's write lock only, and the succeeded result is passed by value from the call site in trackMutationTicket, so the sweep holds no other lock — the succeeded receipt skips itself via the generation guard rather than pointer identity. change.receipt for a superseded failure now stays queryable and reports fresh with the superseding AppliedGeneration, matching your prototype's output and completeMutationWaiters semantics one layer down.

  2. Pending-receipt guard test added. TestMutationFreshnessSuccessKeepsPendingSamePathReceipts per your sketch, plus an assertion that the in-flight receipt is not marked completed by the resolve. All four mutants now bind: call removed → end-to-end test fails; generation guard dropped and path guard dropped → resolve test fails; stamping pending receipts (with completed forced) → the new test fails. Restored tree: the freshness family and the Mutation|Batch|Receipt|FileOps test families pass, go vet clean.

Scope — agreed, and now stated explicitly in the PR body: this change does not touch the workspace_admin.reindex path (IncrementalReindexRepo never sees s.mutationReceipts), and under sustained ingest failure nothing here clears anything until the next success. #692 should stay open for reindex-resolves-receipts, retry-with-backoff, and degrade-instead-of-refuse.

On the two container failures passing on macOS and under -race for you: I'll chase the Linux-container environment on my side separately — agreed it's not this PR's problem.

@zzet
zzet merged commit 71989e0 into zzet:main Aug 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants